Skip to content

feat(runtime, spec): AppPlugin supplies SeedLoaderConfig.locale, so the Seed.locale axis takes effect on the default boot path - #17013

Merged
os-project-manager merged 8 commits into
mainfrom
claude/issue-16595-wire-config-locale
Sep 9, 2026
Merged

os-project-manager merged 8 commits into
mainfrom
claude/issue-16595-wire-config-locale

Conversation

@claude

@claude claude Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #16595

Seed.locale's consumer landed complete in #16510 / PR #16592; its producer did not exist. No first-party call site passed SeedLoaderConfig.locale, so filterByLocale returned its input on its first line and dataset.locale was never read at all on the default boot path. This supplies the producer, then flips the ledger row.

What landed

AppPlugin#resolveSeedLocale reads the app's declared i18n.defaultLocale — the same envelope key, read the same way loadTranslations already reads it for setDefaultLocale — and threads it into all three SeedLoaderRequests this plugin builds:

site path
per-org replayer packages/runtime/src/app-plugin.ts (registered for tenant provisioning)
inline boot seed packages/runtime/src/app-plugin.ts
dev hot-reload seeder packages/runtime/src/app-plugin.ts

An app that declares no locale sends no locale key at all rather than an 'en' default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; a default would have turned a wiring change into a data change, silently dropping a locale: ['zh-CN'] dataset on every stack without an i18n block. A blank or non-string defaultLocale is treated as absence for the same reason.

Why the locale is resolved at the call sites and not inside load(). The card asked me to confirm this shape against resolveEnvConfig, and confirming it changed the answer: env is not resolved at its call sites either — #4704 moved it inside load(), the one funnel, precisely so "call site seven cannot reopen the hole". That remedy does not transfer. resolveEnvConfig can read an ambient, process-wide NODE_ENV; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis genuinely needs a producer at the call sites.

The ledger flip, and the exact scope of its live

packages/spec/liveness/seed.json → seed.locale: experimental → live, with a producer pointer naming AppPlugin#resolveSeedLocale. state-counts.md regenerated by gen:liveness-counts — never hand-edited (seed live 12 → 13, experimental 1 → 0; total live 850 → 851, experimental 6 → 5).

Is live an overclaim at three of six? Stated so it can be disagreed with. The ledger's own criterion is "authoring the property changes runtime behaviour", and the producer field exists so that claim is not made on a consumer pointer alone. After this change, authoring locale on a defineStack() app that declares i18n.defaultLocale changes what gets seeded — proven by the ablation below, not by a passing suite. The #4837 specimen was false in the only sense the criterion has: Seed.env was live while zero of six builders supplied it, so authoring changed nothing anywhere. This is three of six including the entire default boot path, with a real producer cited. So live is correct and bounded, and the row now spells the bound out in its own words — what it asserts, and, marked with a ⛔, what it does not: that it is NOT a claim that every path reaching load() supplies a locale. If a reviewer reads that bound as still too generous, reverting to experimental with the corrected six-site enumeration is a coherent alternative and I would not argue it is wrong — but it would understate a producer that demonstrably bites.

⚠️ The row's note states the frontier rather than claiming it away. This repo has six request builders, the same six the env row one entry up enumerates. This wires the three on the default boot path — which is what makes authoring locale change runtime behaviour, and therefore what makes the row live. The other three are publish/install-time paths, are handed no stack config, and still pass no locale. They are listed in the note by symbol, and filed as #17011.

⚠️ The protocol.ts call site: re-derived, and the card's doubt resolved

The card said PR #16592's body claims a draft-publish call site in protocol.ts while the filing seat's grep at c383352 found none, and told me to trust neither reading.

PR #16592's body was right. packages/metadata-protocol/src/protocol.ts → applySeedBodies builds a SeedLoaderRequest and calls loader.load(parsedRequest.data).

The earlier negative was an anchor artefact, not an absence: a grep for seedLoader.load cannot match that site, because it names its local loader. Re-deriving on SeedLoaderRequestSchema instead — the shape every builder must use — found six, not three, and not four:

The census, in a form that can be re-driven verbatim. Instrument, corpus and controls, at head 06a16c7183:

# THE CENSUS — anchor on the schema every builder must parse through
git grep -n 'SeedLoaderRequestSchema' -- 'packages/*/src/**' | grep -v '\.test\.ts'

# POSITIVE CONTROL 1 — a known-present sibling over the same corpus
git grep -c 'SeedLoaderService' -- 'packages/*/src/**'          # 17 files

# POSITIVE CONTROL 2 — the file the old negative was taken over was being read
grep -c 'publishMetaItem' packages/metadata-protocol/src/protocol.ts   # 34

# THE ANCHOR THAT MISSES TWO — kept so the artefact is reproducible
git grep -n 'seedLoader.load' -- 'packages/*/src/**'            # 4 hits, not 6

Six builders, minus the schema's own definition file:

packages/runtime/src/app-plugin.ts                     :1241 :1337 :1695   3   WIRED here
packages/runtime/src/domains/packages.ts               :1600  applyPublishedSeeds    unwired -> #17011
packages/metadata-protocol/src/protocol.ts             :17180 applySeedBodies        unwired -> #17011
packages/cloud-connection/src/marketplace-install-local-plugin.ts :1513 runInlineSeed unwired -> #17011
packages/spec/src/data/seed-loader.zod.ts              :503   the schema itself, not a builder

The seedLoader.load anchor returns 4 of those 6: it misses protocol.ts and domains/packages.ts, both of which name their local loader. That is the whole artefact, and it is why the census is anchored on the schema rather than on a variable name.

⭐ The pre-flip ledger note itself named only four of the six. That enumeration is corrected in this PR.

⚠️ warnOnUnresolvedLocaleScope STAYS — the judgement, with its reason

⛔ Not deleted, and not kept by default. It is not a signpost for an unwired state that has now gone away:

  • the three publish/install-time builders above still reach load() with no locale;
  • an embedding host that builds its own request still supplies none;
  • a stack that declares no i18n block deliberately sends none.

Every one of those is a live population, and the warning is the only thing standing between them and a silent no-op — which is the property this row spent a release having. Deleting it would re-create the #4837 failure one layer down.

Evidence — the negative leg, not a green suite

⛔ "The tests pass" is not a measurement here: a green suite is equally consistent with the axis still being inert, which is exactly how Seed.env stayed green for releases while unwired. So the new coverage asserts on rows that reach the engine, and it is verified by ablation.

New file packages/runtime/src/app-plugin.seed-locale-producer.test.ts — 7 cases, covering all three call sites (⛔ not seed-loader.test.ts, held by PR #16783).

Ablation (neutralise resolveSeedLocale to return undefined, i.e. the pre-change state):

on-disk proof     text deleted: 1 -> 0 occurrences; text injected: 0 -> 1
                  HEAD blob 71123c8a... -> mutated blob fb2e2973...
result            5 of 7 red, in the INERT direction:
                    expected [ 'account', 'plan_en', 'plan_zh' ]
                          to [ 'account', 'plan_en' ]
                  a locale: ['zh-CN'] dataset LOADS on an `en` stack.
                  All three call sites go red, so the pin covers each one.
                  The 2 that stay green are the absence cases, which
                  assert the unrestricted behaviour the ablated build also has.
restore proof     disk blob back to 71123c8a..., `git diff HEAD` empty

Ablation and restore both ran from a committed state under a trap ... EXIT INT TERM with absolute paths. No permanent ablation artefact is left in the tree.

Verification

Everything below was measured at head 06a16c7183 (post-merge of origin/main; the gate union was re-run after the drift work).

  • pnpm --filter @objectstack/runtime typecheck — exit 0; check:test-typecheck: OK.
  • pnpm --filter @objectstack/runtime exec vitest run --project local — exit 0, 249 files / 3516 tests passed.
  • pnpm --filter @objectstack/spec check:generated — exit 0, all 15 artifacts up to date.
  • Gate families: 98 derived, 98 run, 0 UNRUN (scripts/pm/dispatch-gates.mjs --ran, re-derived on the merged head after the docs edit pulled in the docs families). 96 green; 2 returned exit 3 = PREREQUISITE NOT MET, read as NOT MEASURED, not as green:
    • check:dual-build-cjs-loads — needs a whole-workspace build. This diff adds no export entry, so it cannot move.
    • check:type-check-debt --re-measure — needs the built closure. Its DEBT ledger holds cloud-connection, hono, observability, spec-monorepo; this diff touches none of them.
  • check:engine-double-contract reported RETAINED [findOne] for the new test's engine double and asked for the ledger row; written with the gate's own --write and re-run exit 0 (846 pinned). The double declares neither delete nor update — the seeds are upsert into an empty store, so neither dispatch verb is reachable.

Lint — a declared, measured narrowing, not a skipped run. The repo-wide eslint . --no-inline-config is CI's; this PR ran the affected files and proves the narrowing excluded nothing:

  1. Population, from eslint's own config: eslint.config.mjs's blocks target **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} and packages/**/*.{ts,tsx,mts,cts}. This diff's only files matching any block's files glob are the two below; the rest are .json / .md / .mdx, which no block names.
  2. Count, from --format json: 2 files, 0 errors, 0 warnings, exit 0.
  3. Invariance: this repo never enables type-aware linting. eslint.config.mjs states it in its own words — "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not", recorded there with a positive control. So no untouched file's verdict is a function of this diff.

Changeset — measured, not guessed. ⛔ No skip-changeset: both packages ship changed bytes.

  • npm pack --dry-run --json in packages/spec: 2073 shipped files; liveness/seed.json and liveness/state-counts.md are both present. Positive control package.json present; negative control scripts/ absent.
  • packages/runtime builds to dist, which is its whole files[]; resolveSeedLocale is in dist/index.js, dist/index.cjs, dist/index.d.ts and dist/index.d.cts after pnpm --filter @objectstack/runtime build. Positive control class AppPlugin present in the declarations.

So .changeset/seed-locale-producer-wiring.md: @objectstack/runtime minor, @objectstack/spec patch. check:changeset-no-major exit 0.

Docs drift — the bot's answer, not mine. ⛔ My first pass reported a zero that was not an answer to the question the bot asked: I swept config.locale / SeedLoaderConfig / warnOnUnresolvedLocaleScope / locale-scoped / Seed.locale, and the bot anchors on AppPlugin — the class this diff adds a method to — which is disjoint from every token I swept. The bot's ten rows are re-verified below. Its tree e6f974cb0b and my worktree hold byte-identical blobs for all ten pages (git rev-parse on each path, compared), so the audit is answered on the tree the bot read. ⚠️ Its footer warns its checkout carried uncommitted changes, so its sha is not cited here as a pin — the per-file blob equality is.

page anchor row verdict
content/docs/kernel/services-checklist.mdx true not falsified. Its "AppPlugin Auto-Loading" list is explicitly scoped to loading translation data into the i18n service — the four setters plus the graceful skip — none of which this diff touches. i18n.defaultLocale now has a second consumer, but the list does not claim to enumerate every consumer. Considered adding a cross-reference and declined: it would widen this PR into a page whose subject is the i18n service.
content/docs/protocol/kernel/index.mdx true not falsified. "declared side effects (translations, seed data) run" — seed data still runs; it is now filtered, not suppressed.
content/docs/protocol/kernel/lifecycle.mdx true not falsified. It quotes AppPlugin's optionalDependencies and requiresServices; the delivered diff changes neither, and the quoted excerpt still matches the class.
content/docs/protocol/kernel/plugin-spec.mdx true not falsified. Subject is the module-level onEnable seam AppPlugin.start() invokes; untouched.
content/docs/permissions/authentication.mdx ⚠️ wrong row AppPlugin occurs once, in an import line of a mock-mode config example. Nothing about seeds, locale or start().
content/docs/permissions/capabilities.mdx ⚠️ wrong row Subject is capability registration through the metadata registry. Disjoint from this diff.
content/docs/plugins/index.mdx ⚠️ wrong row A table of auto-load conditions plus a sentence naming which plugins os dev registers.
content/docs/plugins/packages.mdx ⚠️ wrong row One package-catalog blurb: "Runtime Bootstrap — DriverPlugin, AppPlugin, and capability contracts."

Reported rather than silently dropped, as the bot asks: 4 of 8 hand-written rows are wrong, all from the same cause — a class-level anchor on AppPlugin cannot distinguish a page that documents the class's behaviour from one that names it in an import or a catalog list. Four of them are true rows and each was read; none is falsified, so no edit is owed on those eight.

⛔ Release-owned pages, audited read-only and NOT edited: content/docs/releases/v15.mdx names AppPlugin once, in the ADR-0087 protocol-handshake entry; content/docs/releases/v17.mdx names it once, in the onEnable app-bundle contract entry. Neither is touched by this diff and neither is wrong, so there is nothing to file and no docs-only PR is owed.

⚠️ The bot's disclosed blind spot lands on this diff, and was swept by hand. It reports that packages/spec/liveness/seed.json and state-counts.md yielded no anchor, so pages documenting them were invisible to that run — and the experimental → live flip is exactly such a claim. Sweep of content/ for liveness / state-counts / seed.locale / not wired / axis is inert / experimental: 118 lines across many pages (so the sweep fires; the coarse control defineSeed also matches), and not one describes the seed.locale row or its status. The only page that stated the axis's wiring state was content/docs/data-modeling/seed-data.mdx, whose callout told readers some host must supply config.locale without saying the platform now does — that page is falsified by this change, is hand-written and not release-owned, and is corrected here.

⚠️ One live claim outside content/, found by that same sweep and deliberately not edited. The sibling changeset .changeset/seed-locale-axis.md (from the PR that landed the consumer half, unreleased) states in the present tense that no first-party call site supplies config.locale, that the axis is inert on the default boot path, and that the ledger records seed.locale as experimental. This change ends all three. ⛔ It is not rewritten here — it accurately records what its own PR did, and release notes are compiled centrally at release time — so the reconciliation is called out in this PR's own changeset instead, where the compiler cannot miss it. (packages/spec/src/data/seed-loader.zod.ts's "the locale axis is inert" is conditional on "when not specified" and stays true.)

⛔ No page under content/docs/releases/ is edited.

Clause-②: no
Re-derived from the DELIVERED diff, not copied from the dispatch. This supplies a producer, adds a test, flips one liveness row, adds one pinned-ledger row, writes a changeset and corrects one hand-written docs page. It relaxes no accepted set, widens no schema, adds no closed-set member, withdraws no capability and migrates no stored data shape — locale was already on SeedSchema and SeedLoaderConfigSchema before this branch. The behavioural direction is the opposite of widening: a locale-scoped dataset that used to load everywhere now loads only where it is declared. The one accepted set that grows is scripts/engine-double-contract.pinned.json, which is new coverage, written by the gate's own --write. pnpm check:pm-widening-tells exit 0.

验收备注

Findings from this work that are not filed, with the seat that will meet them named:

  • noted, not filed: the pre-flip seed.locale ledger note enumerated four of the six SeedLoaderRequest builders, and one of the four was called unconfirmed when it is real. Corrected in this PR rather than filed. 承接者: whoever next re-verifies this row — the corrected enumeration and the reason the old grep missed two sites are both in the note.
  • noted, not filed: a grep for seedLoader.load is not a call-site census for this family — two of the six builders name their local loader. 承接者: #17011's implementer, who needs the same census; the working expression is recorded in that card's body.

Filed as its own card:

⛔ Out of scope and untouched, as the card requires: reconciling rows already written under a different locale — #16596.


Generated by Claude Code

@github-actions github-actions Bot added the size/m label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec, touching 3 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/liveness/seed.json, packages/spec/liveness/state-counts.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/services-checklist.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/capabilities.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/plugin-spec.mdx (via AppPlugin (symbol, a top-level class))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/releases/v17.mdx (via AppPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/liveness/seed.json, packages/spec/liveness/state-counts.md) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d127f9babce9d483ac4c053194360968e1c7ba0a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 86ea7cc8b5ca09d05cda8cb4c83e5e5a4aca0cbf — the merge of head 06a16c71839eb48d9003a3e80009cdab7632fe32 into base d127f9babce9d483ac4c053194360968e1c7ba0a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 86ea7cc8b5ca09d05cda8cb4c83e5e5a4aca0cbf && git checkout 86ea7cc8b5ca09d05cda8cb4c83e5e5a4aca0cbf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d127f9babce9d483ac4c053194360968e1c7ba0a 06a16c71839eb48d9003a3e80009cdab7632fe32 && git checkout -B drift-repro d127f9babce9d483ac4c053194360968e1c7ba0a && git merge --no-ff 06a16c71839eb48d9003a3e80009cdab7632fe32

node scripts/docs-audit/affected-docs.mjs --json d127f9babce9d483ac4c053194360968e1c7ba0a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d127f9babce9d483ac4c053194360968e1c7ba0a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
…does not, and flag the release-note reconciliation
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Docs Drift Check — all ten rows re-verified. Details are in the PR body under Docs drift; summary here so it sits beside the bot comment.

⛔ My first pass reported a zero that was not an answer to the question the bot asked: I swept config.locale / SeedLoaderConfig / warnOnUnresolvedLocaleScope / locale-scoped / Seed.locale, and the bot anchors on AppPlugin — the class this diff adds a method to. The two token sets are disjoint, so my zero was about a different question. Corrected below rather than re-run.

Answered on the bot's tree: git rev-parse on each of the ten paths shows my worktree and e6f974cb0b hold byte-identical blobs, so the pages read here are the pages the bot read. ⚠️ The bot's sha is not cited as a pin — its own footer says that checkout carried uncommitted changes; the per-file blob equality is what makes the audit answerable.

8 hand-written rows — 4 true, 4 wrong. None falsified, so no edit is owed on any of them.

  • true, read, not falsified: kernel/services-checklist.mdx (its AppPlugin list is scoped to loading translations into the i18n service, and does not claim to enumerate every consumer of i18n.defaultLocale) · protocol/kernel/index.mdx ("declared side effects (translations, seed data) run" — seed data still runs, it is now filtered) · protocol/kernel/lifecycle.mdx (quotes optionalDependencies and requiresServices; this diff changes neither and the excerpt still matches) · protocol/kernel/plugin-spec.mdx (the module-level onEnable seam, untouched).
  • ⚠️ wrong rows, reported as the bot asks rather than dropped: permissions/authentication.mdx (one import line in a mock-mode example) · permissions/capabilities.mdx (capability registration) · plugins/index.mdx (auto-load condition table) · plugins/packages.mdx (one package-catalog blurb). Same cause in all four: a class-level anchor cannot tell a page that documents AppPlugin's behaviour from one that merely names it.

⛔ 2 release-owned pages — audited read-only, NOT edited, and neither is wrong. releases/v15.mdx names AppPlugin in the ADR-0087 handshake entry; releases/v17.mdx in the onEnable app-bundle contract entry. Nothing to file, no docs-only PR owed.

⚠️ The disclosed blind spot lands on this diff, and was swept by hand. packages/spec/liveness/seed.json and state-counts.md yielded no anchor, and the experimental → live flip is exactly such a claim. Sweeping content/ for liveness / state-counts / seed.locale / not wired / axis is inert / experimental returns 118 lines (so the sweep fires), and not one describes the seed.locale row or its status. The one page that did state the axis's wiring state — content/docs/data-modeling/seed-data.mdx — was falsified, is hand-written, and is corrected in this PR.

That sweep also found one live claim outside content/: the sibling changeset .changeset/seed-locale-axis.md still says, in the present tense, that no first-party call site supplies config.locale and that the ledger records seed.locale as experimental. This change ends both. ⛔ It is not rewritten here — it accurately records what its own PR did, and release notes are compiled centrally — so the reconciliation is called out in this PR's own changeset, where the compiler cannot miss it.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 9, 2026 03:16
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit de1a611 Sep 9, 2026
42 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-16595-wire-config-locale branch September 9, 2026 03:34
os-bill added a commit that referenced this pull request Sep 12, 2026
#17013 falsified

`packages/spec/CHANGELOG.md` and `packages/metadata-protocol/CHANGELOG.md`
carry the same compiled 17.4.0 entry for the `Seed.locale` axis. Two of its
present-tense claims were true when written and false by the time the entry
was published: "no first-party call site supplies one" and "the liveness
ledger records `seed.locale` as `experimental`".

Both are corrected to the post-#17013 truth, each followed by one erratum
line naming PR #17013 and the correction date, per the maintainer ruling
recorded on #17026 (director decision batch #119 item 1, 2026-09-12).

The clause "on the default boot path the axis is inert" is falsified by the
same landing and is corrected in the same sentence; the residual bound is
stated rather than dropped — three request builders outside `AppPlugin`
still pass no locale.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…jectstack-ai#17013 falsified, with erratum lines (objectstack-ai#17896)

Fixes objectstack-ai#17026

Clause-②: no — the landing surface is prose inside two already-compiled
`packages/*/CHANGELOG.md` entries plus erratum lines. No schema key, no
accepted-set change, no new exported symbol, no new key on a published
payload. Diff is markdown only; no path under `packages/**/src/**`
moves.

## The ruling this PR implements, verbatim

Director seat, decision batch objectstack-ai#119 item 1, 2026-09-12T04:15Z.
Maintainer's own word to the batch presented as `1A·2A·3A·4D·5A`: 「同意」.

> **A: correct the two published CHANGELOG entries in the repository,
with a post-release erratum note.**
>
> 1. `packages/spec/CHANGELOG.md` and
`packages/metadata-protocol/CHANGELOG.md`: the two `Seed.locale`
sentences ("no first-party call site supplies one", "the liveness ledger
records `seed.locale` as `experimental`") are corrected to the
post-objectstack-ai#17013 truth, each followed by **one erratum line naming PR objectstack-ai#17013
and the correction date**.
> 2. **Before editing**, run the release tooling's dry-run (`changeset
version` on a scratch branch) and record that a manual edit inside an
already-compiled section **survives** — the triage seat's open
confidence gap; **if it does not survive, stop and report**.
> 3. `skip-changeset`: no package publishes anything from this change.

And the boundary that makes A legitimate, also verbatim:

> Immutability belongs to the **shipped npm tarball**, which this ruling
does not and cannot touch; it does not belong to the repository's
markdown.

## Step 2 first — the gate, run before any edit

Run on a local scratch branch off the same base, then discarded. Marker
inserted into the already-compiled `## 17.4.0` section of both files
(into the very paragraph this PR edits), then `pnpm changeset version`.

| reading | value |
|:--|:--|
| marker count before edit | spec **0**, metadata-protocol **0** |
| marker count after edit, on disk | spec **1**, metadata-protocol **1**
(`git diff --stat` = 2 files, 2 insertions / 2 deletions) |
| `pnpm changeset version` | `VERDICT changeset-version-exit 0` — "All
files have been updated" |
| **marker count AFTER `changeset version`** | spec **1**,
metadata-protocol **1** — **the manual edit SURVIVES** |
| where the marker ended up | spec `2451` → `5654`; metadata-protocol
`128` → `898` — pushed down, content intact |
| **lit control** — did the tool actually rewrite the files? | yes: a
new `## 17.5.0` section is prepended at the top,
`packages/spec/package.json` moves `17.4.0` → `17.5.0`, and the 250
pending changesets drop to **0** |
| restore | `git reset --hard` + `git clean -fd`; `git diff HEAD` empty,
0 marker residue, 250 changesets back, scratch branch deleted |

⇒ `changeset version` **prepends** and never rewrites an
already-compiled section. The triage seat's own stated blind spot —
"我**没有验证过手工编辑 CHANGELOG 会不会被下一次发版覆盖或冲突** …… 但我**没有测**" — is now
measured, and it does not block A.

## Step 1 — what changed, and why each corrected sentence is true of
today's tree

Both files carry the same compiled `## 17.4.0` entry for commit
`c1d8f98`, byte-identical before and after this change. The single
paragraph at `packages/spec/CHANGELOG.md:2451` /
`packages/metadata-protocol/CHANGELOG.md:128` becomes four: two
corrected paragraphs, each followed by one erratum line naming PR objectstack-ai#17013
and the correction date 2026-09-12.

**Claim 1 — "no first-party call site supplies one".** Verified false on
this tree, not taken from the card:

- `packages/runtime/src/app-plugin.ts:1717` declares `private
resolveSeedLocale()`, reading `i18n.defaultLocale` off the bundle.
- It is threaded into all three `SeedLoaderRequest`s that plugin builds:
lines `1349`, `1434`, `1791` (`...(seedLocale ? { locale: seedLocale } :
{})`).

⇒ the corrected sentence says a first-party call site **does** supply
`config.locale` and names the producer and the three sites.

**Claim 2 — "the liveness ledger records `seed.locale` as
`experimental`".** Verified false: `packages/spec/liveness/seed.json` →
`props.locale.status` reads **`live`**, with `producer` naming
`packages/runtime/src/app-plugin.ts#resolveSeedLocale`.

**One adjacent clause corrected in the same stroke, named rather than
smuggled.** "on the default boot path the axis is inert" sits in the
same paragraph and is falsified by the same landing. Leaving it would
have left a known-false clause beside a corrected one, which defeats the
ruling's own purpose; it is corrected and called out here.

**The corrected text does not overclaim.** The ledger row's `live` is
explicitly bounded, and the correction carries that bound rather than
dropping it. Measured on this tree: three request builders outside
`AppPlugin` still build a `config` with no `locale` key —

- `packages/runtime/src/domains/packages.ts:1776` `applyPublishedSeeds`
(package apply) — `config: { defaultMode, multiPass, organizationId? }`,
zero occurrences of `locale` in the whole file;
- `packages/metadata-protocol/src/protocol.ts:17490` `applySeedBodies`
(draft publish) — same three keys;
-
`packages/cloud-connection/src/marketplace-install-local-plugin.ts:1510`
`runInlineSeed` (marketplace install) — same three keys.

And `SeedLoaderService#warnOnUnresolvedLocaleScope` still exists
(`packages/metadata-protocol/src/seed-loader.ts:2592`, called at
`:554`), so the corrected paragraph keeps the sentence about the warning
instead of deleting it.

## House convention followed rather than invented

The dispatch pointed at objectstack-ai#16859 as the precedent. ⚠️ Read to the end,
**objectstack-ai#16859 did not in fact edit a published CHANGELOG** — its own report
states `packages/spec/CHANGELOG.md` "was not opened for writing" and
that correction 1 was "confirmed foreclosed and left alone". So it
establishes no erratum wording.

The convention that does exist was found by measurement — three entries
already corrected in place in this repo:

- `packages/spec/CHANGELOG.md:5257` — "As published, this sentence
justified that by … that was false when written and is false now …"
closing `(Corrected after publication, objectstack-ai#15058.)`
- `packages/spec/CHANGELOG.md:7401` and
`packages/lint/CHANGELOG.md:1214` — same shape, closing `(Corrected
after publication, objectstack-ai#15026.)`

⇒ this PR follows that: the published sentence is quoted in place and
marked false, the correction follows in the same entry, and the last
erratum line closes `(Corrected after publication, objectstack-ai#17026.)` naming the
card, as those three name theirs.

It is also exactly what the `packages/*/CHANGELOG.md` guardrail row now
added to AGENTS.md by objectstack-ai#16849 prescribes: "Factual error in a released
entry → **amend that entry in a dedicated docs-only PR**, ⛔ never an
erratum in a later entry and never a rider on code changes". This PR is
docs-only, amends the entry itself, and adds nothing to a later entry.

## `skip-changeset` — measured, not asserted

| reading | value |
|:--|:--|
| diff file list vs merge base `c744c0af332` | exactly two paths, both
`CHANGELOG.md` |
| any non-markdown path | **none** |
| `.changeset/*.md` added by this PR | **0** |
| `packages/spec` `files[]` | `dist, json-schema, liveness, prompts,
llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface,
spec-changes.json` |
| `packages/metadata-protocol` `files[]` | `dist, README.md,
CHANGELOG.md` |

⚠️ Stated precisely so neither half misleads: `CHANGELOG.md` **is** in
both packages' `files[]`, so the corrected bytes do ship inside the next
tarball either package happens to publish. What does **not** move is any
package **version**, any **API**, any `dist/`, any source file, or any
generated artifact — and because this PR adds no changeset, it schedules
no version bump of its own. That is the sense in which "no package
publishes anything from this change" holds.

## Gates

Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` against the real change set, then reconciled
with `--ran`.

- **54 derived families — 50 run green, 4 NOT MEASURED, 0 unrun.**
Reconciliation verdict, verbatim: `✓ dispatch-gates --ran: 54 derived
famil(ies) accounted for — 50 run, 4 NOT-MEASURED (4 DERIVED from a
recorded exit 3).` Every exit code captured before any pipe.
- The 4 NOT MEASURED are `check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`; all exit **3** = PREREQUISITE NOT
MET, each refusing because there is no `dist/` in this worktree ("Run
`pnpm build` first. ⛔ This is NOT a pass: nothing was measured."). They
read build artifacts only, which a markdown-only diff cannot move.
**Declared to CI**, which builds first.
- Repo-wide `pnpm lint` (`eslint . --no-inline-config`) over the
**full** population: exit **0** in 1m26s. No narrowing declared because
none was needed. A targeted run over just these two files reports `File
ignored because no matching configuration was supplied.` for both —
markdown is outside eslint's configured population here.
- `pnpm check:nul-bytes` green, plus a manual control-character sweep of
both touched files (`grep -naP` over the non-tab, non-newline C0 range
plus DEL): **no matches**, with a printable lit control on the same
files firing.
- Package test suites are **not owed and were not run**, stated rather
than silently skipped: the diff compiles into nothing and is imported by
nothing. The only test that mentions `CHANGELOG.md` as a file class is
the `action-owner-key-single-source` pair, whose scanner is fixed to
`.ts` sources.

## Acceptance notes — out of scope, noted, not filed

- `packages/core/src/plugin-contract.ts:69` carries a code comment
asserting the opposite posture for a different entry: "⚠️ `CHANGELOG.md`
is NOT a live document and is deliberately not corrected … supersedes
them BY VERSION rather than by rewriting them." That predates the
`packages/*/CHANGELOG.md` guardrail row objectstack-ai#16849 added to AGENTS.md, which
now prescribes amending the entry. ⛔ Not touched by this PR and not
filed — it belongs to whoever next revisits that entry, and the
governing text is already the AGENTS.md row.

## Not in play

⛔ `content/docs/releases/` is untouched by this PR and by all of the
ruling's options. This is `packages/*/CHANGELOG.md`, a different
surface. Named here only so the red line is not read into a task it does
not cover.

Authored by the `domain:spec` execution lane, session
`session_01MkQhmuuJAVDjmeWNixwDDH`, on branch
`claude/issue-17026-seed-locale-changelog-erratum`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ectstack-ai#17783/objectstack-ai#17785 (objectstack-ai#18077)

Fixes objectstack-ai#18076

Clause-②: no — the landing surface is prose inside one already-compiled
`packages/spec/CHANGELOG.md` entry plus an erratum line. No schema key,
no accepted-set change, no new exported symbol, no new key on a
published payload, and no path under `packages/**/src/**` moves.

`skip-changeset`: no package publishes anything from this change.

## The ruling this PR implements

Director seat, decision batch objectstack-ai#119 item 1, 2026-09-12T04:15Z,
maintainer's own word `1A·2A·3A·4D·5A`: 「同意」, implemented once already
by `ef47459474` (objectstack-ai#17896). This card applies the same remedy shape to a
second falsified instance:

> **A: correct the two published CHANGELOG entries in the repository,
with a post-release erratum note.** … the … sentences … are corrected to
the post-objectstack-ai#17013 truth, each followed by **one erratum line naming PR
objectstack-ai#17013 and the correction date**.
>
> **Before editing**, run the release tooling's dry-run (`changeset
version` on a scratch branch) and record that a manual edit inside an
already-compiled section **survives** … **if it does not survive, stop
and report**.
>
> `skip-changeset`: no package publishes anything from this change.

## Step 2 first — the survival gate, run before any edit

On a local scratch branch off `484484041a`
(`scratch/issue-18076-survival-gate`), discarded afterward:

| reading | value |
|:--|:--|
| marker inserted into the already-compiled `## 17.4.0` "Keys
deliberately left alone" paragraph | `SURVIVALGATEMARKER18076XQ7` |
| marker count before `changeset version` | **1** |
| dark control (`ZZQV18076NOPE`, invented at read time) before | **0** |
| `pnpm changeset version` | exit 0, "All files have been updated" |
| marker count **after** `changeset version` | **1** — the manual edit
**survives** |
| where the marker ended up | line 2869 → line 7316 — pushed down,
content intact |
| dark control after | **0** — unchanged, confirming the grep
methodology (not a broken instrument reading a false zero) |
| lit control — did the tool actually rewrite the files? | yes: a new
`## 17.5.0` section is prepended, `packages/spec/package.json` moves
`17.4.0` → `17.5.0`, and 452 of 453 pending `.changeset/*.md` files are
consumed |
| restore | `git checkout -f` back to the real branch; `diff <(git show
origin/main:packages/spec/CHANGELOG.md) packages/spec/CHANGELOG.md` →
**identical**; scratch branch deleted |

⇒ `changeset version` prepends a new section and never rewrites an
already-compiled one. Re-measured on this tree rather than inherited
from `ef47459474`'s reading, per the ruling's explicit instruction.

## What changed, and why each corrected claim is true of today's tree

`packages/spec/CHANGELOG.md`, inside the already-released `## 17.4.0`
entry (commit `e9fcd6b`, "Keys deliberately left alone"), said:

> `ServiceLevelObjective.errorBudget`'s burn-rate `window` and the
OpenTelemetry exporter `timeout` name no unit anywhere in their prose,
so both are outside the gate's population entirely. Pin tests assert
each of these, so a later sweep cannot read this card as "every
duration-shaped number on these files".

All three claims are false, verified on this tree independently of the
card:

- **Half 1** — `packages/spec/src/system/metrics.zod.ts:368-375`
(`SLO_BURN_RATE_WINDOW_RETIRED`):
`ServiceLevelObjective.errorBudget.burnRateWindows[].window` was renamed
to `durationSeconds` in `@objectstack/spec` 17 (objectstack-ai#17783), tombstoned with
`retiredKey()` at `metrics.zod.ts:402`/`:498`. The pin that held the
bare spelling is now `metrics.test.ts:612`, `it('REFUSES the burn-rate
\`window\` with a rename naming \`durationSeconds\`', …)`.
- **Half 2** — `packages/spec/src/system/tracing.zod.ts:546-550`:
`OpenTelemetryCompatibility.exporter.timeout` was renamed to `timeoutMs`
(objectstack-ai#17785), tombstoned with `retiredKey()`; its `batch.exportTimeout`
sibling moved to `exportTimeoutMs` the same way (`:586`/`:601`). The pin
is now `tracing.test.ts:594-611`, `it.each([['exporter.timeout',
'timeoutMs'], …])('REFUSES the retired \`%s\` with the rename in the
message', …)`.

The corrected paragraph quotes the falsified sentence, states the truth
with both PR numbers and both new key names, and closes with one erratum
line naming this PR and the correction date, matching `ef47459474`'s
shape.

**The paragraph's other four claims were checked and are still true**
(not touched, per the ruling's scope boundary):

- `FailoverConfig.dns.ttl` — still bare, `disaster-recovery.zod.ts:148`,
under the same `externalVocabulary` exemption comment.
- `CacheAvalanchePrevention.lockout.lockTimeoutMs` — still milliseconds,
`cache.zod.ts:162`.
- `MetricExportConfig.batch.size` — still a bare record count,
`metrics.zod.ts:702`.
- `QueueConfig.rateLimit.max` — still a bare task count,
`worker.zod.ts:~276`.

## The erratum line's PR number — how it went in, and the fix for a
review finding

`objectstack-ai#18077` (the PR number for this correction) is not knowable before the
PR exists, so the first pushed commit (`bfc744c98c`) carried a
placeholder, `#PRPLACEHOLDER18076`, in the erratum line's parenthetical,
meaning to fill it in once the PR was open. **That fill-in commit
(`ac68eff364`) was made locally but not pushed before the round's first
status check ran against `bfc744c98c`, so a reviewer correctly caught a
shipped placeholder.** It is now pushed; **HEAD is `ac68eff364`**,
verified directly against the remote rather than assumed:

```
$ git show origin/claude/issue-18076-changelog-duration-keys-erratum:packages/spec/CHANGELOG.md | grep -c PRPLACEHOLDER
0
$ git show origin/claude/issue-18076-changelog-duration-keys-erratum:packages/spec/CHANGELOG.md | grep -n 'Corrected after publication, objectstack-ai#18077'
7331:  population as of `@objectstack/spec` 17. (Corrected after publication,
```

**Placeholder sweep of the whole diff, with controls** (`git diff
origin/main..HEAD -- packages/spec/CHANGELOG.md`, 35 lines):

- Pattern
`PLACEHOLDER|\bTODO\b|\bTBD\b|\bXXX\b|\bFIXME\b|<[A-Z_]+>|\{\{.*\}\}`
(case-insensitive) over the diff: **0 matches**.
- Lit control, drawn a different way: appended a synthetic
`PRPLACEHOLDER99999` line to the same scanned text and re-ran the
identical pattern — **1 match**, confirming the regex class actually
fires and the 0 above is a real zero, not a typo'd pattern reading a
false clean.
- Positive control: `18077` appears **exactly once** in the diff — the
one erratum reference, present and correctly substituted.

## Verification

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived **54** local gate families for this
diff (all matched via broad `packages/**` / `packages/spec` globs — this
path carries no gate of its own). Ran all 54 **twice** — once on
`bfc744c98c`, again on the final `ac68eff364` after the placeholder fix
landed — both times reconciled with `--ran` against the recorded exit
codes, same result both runs:

```
Run reconciliation — 54 derived, 50 run, 4 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 54 derived famil(ies) accounted for — 50 run, 4 NOT-MEASURED (4 DERIVED from a recorded exit 3).
```

- **50/50 that ran, passed** (`pnpm check:nul-bytes`,
`check:release-notes`, `check:release-page-status`,
`check:release-section-coverage[--strict]`,
`check:changeset-gate-self-tests`, `check:doc-authoring`,
`check:page-declaration-shape`, `check:published-files`,
`check:slot-lookup`, `check:test-source-alias`,
`check:type-source-resolution`, and 39 more — full list in this branch's
derivation).
- **4 NOT-MEASURED, exit 3 (`PREREQUISITE NOT MET`), declared rather
than swallowed**: `check:dts-closure`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:sourcemap-no-sources-content` — each
refuses on a missing `dist/` in this fresh worktree ("Run `pnpm build`
first … this is NOT a pass: nothing was measured"). These are whole-repo
build-completeness gates unrelated to this diff's content (no code, no
export, no `dist` path moves); running a ~80-package `pnpm build` to
satisfy them is disproportionate to a one-line CHANGELOG correction, and
CI builds fresh before running them.
- No changeset added; `check:changeset-gate-self-tests` (the gate's own
self-test) is green. `skip-changeset` now shows as an applied label on
this PR (not applied by this session — labels were left untouched per
this card's operating constraints).
- `pnpm check:nul-bytes` and a direct `grep -naP
'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` both clean on the diff.
- `node scripts/check-commit-card-trailers.mjs --range
origin/main..HEAD`: clean on both commits — no card-relation trailer,
model-free trailer pair.

No `packages/spec` test/typecheck run beyond the above: this diff
touches no code, no export and no public surface byte, so per this
repo's verification-scope convention there is nothing for `pnpm --filter
@objectstack/spec test`/`typecheck` to newly exercise.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt

---
_Generated by [Claude
Code](https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants